Skip to content

fix(runtime): the environment-membership check judges the principal the request resolved - #31396

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-fayfxc
Nov 8, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-fayfxc

Conversation

@os-project-manager

@os-project-manager os-project-manager commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Leehom · project thread

Refs #28284

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:多环境主机上,dispatcher 的环境成员检查(403 PROJECT_MEMBERSHIP_REQUIRED)现在判断的是请求解析出的主体。由 API key 或 OAuth access token 接纳的主体,按 key 的所有者或 token 的用户判断(同时带了别人 cookie 也一样),平台组织豁免使用解析时核验过的组织。由 session cookie 接纳的主体,判断方式与原来完全相同。
  • 为什么改:QA 第三轮发现的安全加固(细节按披露裁决不公开)
  • 风险与回滚:只改 @objectstack/runtime(patch)。单环境主机,以及关闭了该检查的主机(api.enforceProjectMembership: false)不受影响。会被新拒绝的只有:多环境主机上,以 API key 或 OAuth token 访问某环境作用域路由、但不是该环境成员的调用方。回滚即 revert 本 PR。
  • 先看:packages/runtime/src/http-dispatcher.ts 的 enforceProjectMembership,session 读取之后按 admittingCredentialKind 新增的分支;以及新文件 packages/runtime/src/security/admitting-credential.ts。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/http-dispatcher-membership-resolved-principal.test.ts,预期 8/8。
  • 需要你做的:无,席位做 contract review 后落地。

What changes

  • New admittingCredentialKind(request) (packages/runtime/src/security/admitting-credential.ts) is the one reading of which credential admitted a request's caller.
    • It answers oauth when the resolved context carries OAuth scopes.
    • Otherwise it answers what the identity step's admission recorded: api_key or session.
    • It answers undefined when nothing was recorded.
    • apiForwardCredentialKind (action execution) now delegates to it, so the api-action forward and the membership check cannot disagree.
  • HttpDispatcher.enforceProjectMembership: when that reading is api_key or oauth, the check judges the principal resolveRequestScope resolved.
    • That principal is executionContext.userId: the key's owner or the token's user. This holds even when a session cookie for another user rides beside it.
    • The platform-organization bypass reads executionContext.tenantId, the organization the resolution vetted.
  • A session-admitted principal keeps today's path exactly: the check's own session read, the session's active organization, and the fail-open when that read throws.
  • Unchanged:
    • hosts without environment scoping (no kernel-resolver, so no environmentId);
    • hosts that turn the check off;
    • the control-plane, share-link, approval-action and inbound-hook exemptions.

Why

QA 第三轮发现的安全加固(细节按披露裁决不公开)

Verification

Readings on head 44a099f05: two commits on main at c2bc76e3e. The second answers the contract review: one shared credential reader, and a pin on a scoped data route.

Pins, red-first and restore
  • New packages/runtime/src/http-dispatcher-membership-resolved-principal.test.ts (8): a multi-tenant host with a stub resolver and sys_environment_member, driven through dispatch().
    • These are refused, and the refusal names the judged user:
      • a non-member admitted by an OAuth access token on /mcp;
      • the same token sent beside a member's cookie;
      • a non-member admitted by an API key on /mcp;
      • a non-member admitted by an API key on the scoped data route /data/task.
    • CONTROLs:
      • A cookie non-member is refused as before.
      • A member admitted by a token passes. So does a member admitted by a key. Each makes one membership read for that user.
      • A request with no credential is not judged here.
  • Red-first: with http-dispatcher.ts set back to its main blob, the file read 6 failed | 2 passed (8).
    • The four refusal cases failed.
    • The two member CONTROLs failed only because no membership read was made.
    • The cookie and no-credential CONTROLs stayed green.
  • Restored (blob equal to HEAD): 8 passed (8).
Package suite and typecheck
  • tsc --noEmit in packages/runtime: exit 0.
  • pnpm --filter @objectstack/runtime test under the verify lock: Test Files 556 passed (556), Tests 8965 passed | 19 skipped (8984), VERDICT command-exit 0.
    • An earlier cut of this change judged any resolved principal that differed from the check's own session read. This suite's domains/meta-read-principal-filtering.measurement.test.ts caught it, because the two reads can disagree for a session principal.
    • The arm is now keyed on the admitting credential, and that file is green.
    • The action-execution forward tests are green on the shared reader.
Derived gates
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 69 families on this diff. Every one ran and exited 0; check:type-check-debt ran under the verify lock.
  • --ran reconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN.
  • eslint --no-inline-config on the changed source files: exit 0.

Not measured:

  • An end-to-end multi-environment boot. No kernel-resolver ships in this repository, so the pins drive dispatch() with a stub resolver.
  • The dogfood battery. Its single-environment boots set no environmentId, so they never reach this check.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; eslint --no-inline-config on the changed files: exit 0.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English, except the maintainer summary the lane's PRs carry.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…he request resolved

On a multi-environment host the dispatcher's environment-membership check
now judges the principal the request resolved, whichever credential admitted
it. A principal an OAuth access token or an API key admitted is judged as the
token's user or the key's owner, in the organization its resolution vetted,
including when a session cookie for another user is sent beside it. A session
principal is judged as before.

Clause-②: no
Metadata-protocol: none

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mgePB9NQ4PNhvYVxk
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 8, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract-review-claim: session_01FEkQ5o8PE7ZtVjcor51wBZ · head bb57aaf · 2026-11-08T05:58Z

@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 8 documentable anchor(s).

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/knowledge-rag.mdx (via executionContext (symbol, a field of interface AdmittingCredentialFacts))
  • content/docs/api/data-api.mdx (via api_key (literal, a string literal in AdmittingCredentialKind; a string literal in ApiForwardCredentialKind; a string literal in admission; a string literal in enforceProjectMembership))
  • content/docs/api/environment-routing.mdx (via enforceProjectMembership (symbol, a method of class HttpDispatcher))
  • content/docs/automation/connectors.mdx (via api_key (literal, a string literal in AdmittingCredentialKind; a string literal in ApiForwardCredentialKind; a string literal in admission; a string literal in enforceProjectMembership))
  • content/docs/automation/jobs.mdx (via executionContext (symbol, a field of interface AdmittingCredentialFacts))
  • content/docs/data-modeling/field-type-decision-tree.mdx (via api_key (literal, a string literal in AdmittingCredentialKind; a string literal in ApiForwardCredentialKind; a string literal in admission; a string literal in enforceProjectMembership))
  • content/docs/data-modeling/field-types.mdx (via api_key (literal, a string literal in AdmittingCredentialKind; a string literal in ApiForwardCredentialKind; a string literal in admission; a string literal in enforceProjectMembership))
  • content/docs/protocol/kernel/config-resolution.mdx (via api_key (literal, a string literal in AdmittingCredentialKind; a string literal in ApiForwardCredentialKind; a string literal in admission; a string literal in enforceProjectMembership))
  • content/docs/protocol/knowledge.mdx (via executionContext (symbol, a field of interface AdmittingCredentialFacts))
  • content/docs/protocol/objectql/types.mdx (via api_key (literal, a string literal in AdmittingCredentialKind; a string literal in ApiForwardCredentialKind; a string literal in admission; a string literal in enforceProjectMembership))
  • content/docs/protocol/objectui/concept.mdx (via api_key (literal, a string literal in AdmittingCredentialKind; a string literal in ApiForwardCredentialKind; a string literal in admission; a string literal in enforceProjectMembership))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 64 of 227 client-bound route-ledger rows — the other 163 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 163: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 405a78eae9311598f8868fe5bff92a2e17520f68 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4f3c1d8acec129c9d45dd243442e65c93535d09f — the merge of head 44a099f055c854599584b254e08b3f65ecc839e7 into base 405a78eae9311598f8868fe5bff92a2e17520f68, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4f3c1d8acec129c9d45dd243442e65c93535d09f && git checkout 4f3c1d8acec129c9d45dd243442e65c93535d09f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 405a78eae9311598f8868fe5bff92a2e17520f68 44a099f055c854599584b254e08b3f65ecc839e7 && git checkout -B drift-repro 405a78eae9311598f8868fe5bff92a2e17520f68 && git merge --no-ff 44a099f055c854599584b254e08b3f65ecc839e7

node scripts/docs-audit/affected-docs.mjs --json 405a78eae9311598f8868fe5bff92a2e17520f68

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 405a78eae9311598f8868fe5bff92a2e17520f68 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bb57aaf4a2315b02a9a2f05bbb639177125807d2
Local-runs: none
Scope: packages/runtime/src/http-dispatcher.ts, packages/runtime/src/http-dispatcher-membership-resolved-principal.test.ts + packages/runtime/src/security/resolve-execution-context.ts, packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/assemble-execution-context.ts, packages/core/src/security/api-key.ts, packages/runtime/src/domains/keys.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/api-endpoint-step.ts, packages/rest/src/rest-server.ts, packages/rest/src/rest-api-plugin.ts, packages/spec/src/stack.zod.ts, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/contracts/auth-service.ts, packages/spec/src/api/error-code-ledger.zod.ts, content/docs/api/environment-routing.mdx, content/docs/api/wire-format.mdx, content/docs/ai/agents.mdx, packages/runtime/src/http-dispatcher.test.ts, packages/runtime/src/http-dispatcher.mcp-oauth.test.ts, .changeset/environment-membership-judges-resolved-principal.md (read for ②)

Inputs: the PR body, its one commit and message, the three-path net diff against main (merge-base c2bc76e3e; main has since moved to 77a675db1 with no change to any cited file), the check-runs on the head, the published spec and the cited code on main, and the drafter's private hand-back, whose claims were checked against the tree. No card, by the maintainer's ruling for this series; Refs #28284 is the series mailbox, closed, with nothing on it about this PR. Why line, as fixed: 「QA 第三轮发现的安全加固(细节按披露裁决不公开)」.

Check-runs on the head at the time of writing: 47 runs, 42 completed (29 success, 13 skipped, none failed, none cancelled), 5 still in progress (Test Core shards 1 to 5). Among the completed successes: Check Changeset, Check PR Size, PR-body guards, Governed Surface Queue Guard, Build Core, Test Core shards 6 to 8, every Type Check family, every Lint family, Dogfood Regression Gate, Test UI, Live E2E UI, Flag docs affected by code changes. Those conclusions are the gate verdicts this record relies on; the verdict below stands on the diff and the completed runs, the seat gates landing on the remaining runs finishing green, and a red run reopens this record.

① Derived judgments

  1. Accept set of the dispatcher's scoped routes on a multi-environment host, narrowed on one class of caller. HttpDispatcher.enforceProjectMembership (http-dispatcher.ts 1589-1815 on the head) runs after resolveRequestScope and the ADR-0069 gate and before every domain body (dispatch() 2942-2962), for every route except the control-plane skip list (/auth, /cloud, /health, /ready, /discovery), the share-link resolve and messages routes, the exact approvals act route, the inbound hook POST, requests with no environmentId, and the system environment. On a request a host KernelResolver placed in an environment, a caller admitted by an API key (any of those routes) or by an OAuth access token (/mcp, plain or environment-scoped spelling, the only face that accepts one) is now judged as the resolved principal — the key's owner or the token's user — and refused 403 PROJECT_MEMBERSHIP_REQUIRED when that user holds no sys_environment_member row in the resolved environment. Right against the published contract on main: api.enforceProjectMembership is declared as the "Per-environment membership 403 gate (dispatcher)" (stack.zod.ts 1073), content/docs/api/environment-routing.mdx says every request an environment resolves is served "behind the same project-membership and capability gates as the scoped URL", dispatch()'s own preamble says the gate holds scoped data, meta, AI and automation routes on sys_environment_member, and nothing on main declares a credential-shaped exemption.
  2. The admitting-credential test is read off facts the identity step already writes. context.admission.credential === 'api_key' is written by the shared key-admission seam through onAdmission (resolve-execution-context.ts 297; resolve-authz-context.ts 620-626); principalKind === 'agent' is decided by the shared assembler on an OAuth client (assemble-execution-context.ts 320, 343); oauthScopes is set only from an OAuth provenance (396). A session-admitted human carries none of the three. Right, with one cohesion finding in ④ (should-fix 1).
  3. The organization the bearer arm hands the platform bypass is the vetted one. executionContext.tenantId for a key is the row's active_organization_id, server-written at mint from the minter's active organization (keys.ts 349-358) and refused as organization_membership_ended when no current membership backs it under a wall (resolve-authz-context.ts 716-724); for a token it is the consent organization the verifier returns, vetted by vetOrganizationClaim on the session arm (resolve-execution-context.ts 258-268; resolve-authz-context.ts 795-812), the same rule content/docs/ai/agents.mdx publishes. It is the value every /meta and /packages door already reads through resolveActiveOrganizationId. Right.
  4. Precedence when two credentials ride one request. A key beside a cookie, or a token beside a cookie on /mcp: the bearer decides, as the identity step already decides it (a key sets the principal before the session arm is reached; a presented token never falls back to a cookie) and as enforceAuthGate judges it under ruling C′ item 3. Right, and pinned (test case 3).
  5. A failed session read no longer fails open for a bearer-admitted principal. The else if (sessionUnread) return null sits behind the bearer arm, so the gate judges a key or token principal whether or not the cookie read answered; a session-only request keeps the fail-open. Right: that read is not the bearer principal's admission.
  6. Unchanged, verified by reading: single-environment hosts (no resolver, so no environmentId: return at 1667), api.enforceProjectMembership: false (1594), the skip list and the three token-is-the-authorisation exemptions, the system environment, the session-admitted path (same read, same organization claim, same fail-open), the refusal shape (403, code: PROJECT_MEMBERSHIP_REQUIRED, details: { environmentId, userId } as content/docs/api/wire-format.mdx documents it — the user named is now the one judged), the positive-membership cache keyed environment:user. Right.
  7. Public surface: unchanged. No export, type or schema moves; Metadata-protocol: none is right.
  8. Pins. The new file drives dispatch() on a stub multi-environment host with a tenant kernel: refusal and membership-read for a token non-member, for a token non-member beside a member's cookie, and for a key non-member; passage with one read for a token member and a key member; the cookie non-member CONTROL; the no-credential CONTROL (not judged here, as before). The seven cases match the derived judgments above. Red-first and restore readings in the PR body are consistent with the tree; not reproduced (read-only).

② Semver level

  • @objectstack/runtime: patch. Right: a bug fix in a released package, no protocol kind, and skip-changeset would have been wrong.
  • Clause-②: no — correctly no, not no (narrowing). The capability the package declares is the per-environment membership 403 gate on scoped routes, in the stack schema, the routing docs and the dispatcher's own preamble; none of them declares that a caller admitted by a key or a token is served a scoped route without being a member, and no published text promises such a caller anything on an environment they are not a member of. What this PR refuses is therefore not a declared capability taken away at the consumer but the spec's own refusal delivered for the remaining credentials — an implementation gap closed where it sits, which is the ⑧ reading the hand-back offers and the one this record confirms. Nothing an author can write is removed or renamed, so there is no FROM-to-TO migration to carry. Precedents in this series, fix(cli): os serve refuses to boot when a plugins entry that may provide the crypto provider does not load #31263 and fix(cli): os serve refuses to boot a standalone stack whose plugins lists an entry that may provide the crypto provider #31320, declared Clause-②: no at patch for a refusal newly made real on the same basis and were reviewed and merged on it. The record is nonetheless rendered on the narrowing face at CONTRACT_REVIEW_TIER, so the scrutiny a no (narrowing) would owe was applied. Both declaration lines are present and identical in the PR body and the changeset; the changeset body names the package, the level, who is refused, who is not, and the two unchanged host postures.
  • Disclosure check. PR title and body (both languages), commit message, changeset body, the new source comment, the new test file's header and case names were read. All stay at the level of behaviour: which callers are refused, which are not, where the check sits, what the refusal answers, and the maintainer's fixed why line. The verification paragraph lists which cases are red against main's blob, which is the same level as the body's own 「会被新拒绝的只有」 line and the precedents' verification sections. No sentence states the mechanism the ruling keeps private. No breach.

③ Boundary flags

  • Flag: "Clause-②: no on the ⑧ reading — the reviewer judges." Answered in ②: no stands.
  • Flag: the platform bypass for a bearer principal uses the vetted organization, not a raw claim. Confirmed in the tree (① 3).
  • Flag: reach is a host with a kernel-resolver; none ships in this repository; no end-to-end multi-environment boot measured. Confirmed by reading (isMultiTenantHost, environment-routing.mdx: multi-environment hosts ship from a separate distribution). The stub-resolver pins through dispatch() are the right instrument here; accepted. The dogfood battery never sets an environmentId, so it cannot reach this check; accepted.
  • Flag: the first cut keyed the arm on a disagreement between two reads and was withdrawn. The withdrawal is right: that rule reached session-admitted principals whose two reads can differ for reasons unrelated to the credential. The credential-keyed arm is the correct discriminator; see ④ should-fix 1 for how it should be spelled.
  • Row ⑩-a (out of scope, not done). One pre-existing observation outside this PR, not opened or widened by it. Escalated as its own private item in this series.
  • Found in review, outside this PR (two items). Both pre-existing, neither opened or widened by this PR. Escalated as their own private items; details stay off GitHub under the disclosure ruling.
  • Hand-back verification claims (red-first 5 failed / 2 passed against main's blob, restored 7/7, runtime suite 556 files / 8964 tests, tsc 0, eslint 0, 67/67 derived gates): consistent with my reading of the tree; not reproduced; the head's check-runs are the gate verdicts.
  • No open question in the hand-back is left unanswered.

④ Design judgment

The narrowing was read against the published spec on main and the live code, and the gate was pushed on every caller shape that reaches it.

  • Does it take a declared capability away at the consumer? No. The capability declared is the membership gate itself; a key or a token is a way of being admitted as a user, and nothing on main promises that user a scoped route in an environment they are not a member of. The refusal routes to the same remedy a cookie non-member has: membership in that environment.
  • Does it make the spec's own refusal real? Yes. The gate now judges the one principal the request executes as, whichever credential admitted it, which is what the schema line, the routing docs and the dispatcher's preamble already say it does.
  • Shapes tried. Every credential kind and combination the gate can meet, the bypass, and the failure paths around admission (detail kept off this record under the disclosure ruling). None reaches past the gate after this PR.
  • Should-fix 1 (http-dispatcher.ts, the new bearerAdmitted predicate): the gate infers "a bearer admitted this principal" from three facts, one of which is the admission channel (admission.credential) and two of which are envelope fields (principalKind, oauthScopes) that the shared assembler derives from the same provenance. The identity step already knows which credential admitted the principal and already reports it on onAdmission for a key; a token-admitted principal is reported there as 'session', which is why the gate reaches past it. Report the token provenance on the admission channel too (one more value on RequestAdmission.credential, written where the established session for a token is built) and have the gate read that one fact. Today the three disjuncts are value-identical to it, so no behaviour changes; the finding is a second spelling of the admission fact, which a future provenance could drift from.
  • Nit (test file): the key arm is pinned on /mcp only, while it holds on every dispatcher route; one scoped data or meta route with a key would pin the route-independence. No effect on correctness or the contract.
  • No must-fix.

Implemented-by: claude/project-thread-fayfxc
Reviewed-by: session_01FEkQ5o8PE7ZtVjcor51wBZ

VERDICT: PASS


Generated by Claude Code

…ad one admitting-credential kind

`admittingCredentialKind` (runtime security) is the one reading of which
credential admitted a request's caller: `oauth` when the envelope carries
OAuth scopes, else the identity step's recorded admission. The
environment-membership check and `apiForwardCredentialKind` both read it, so
the two cannot disagree. Adds a pin for a principal an API key admitted on a
scoped data route.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5mgePB9NQ4PNhvYVxk
@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract-review-claim: session_01FEkQ5o8PE7ZtVjcor51wBZ · head 44a099f · 2026-11-08T06:51Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 44a099f055c854599584b254e08b3f65ecc839e7
Local-runs: none
Scope: packages/runtime/src/http-dispatcher.ts, packages/runtime/src/http-dispatcher-membership-resolved-principal.test.ts, packages/runtime/src/action-execution.ts, packages/runtime/src/security/admitting-credential.ts + packages/core/src/security/assemble-execution-context.ts, packages/core/src/security/resolve-authz-context.ts, packages/core/src/security/request-execution-context.ts, packages/runtime/src/security/resolve-execution-context.ts, packages/rest/src/forward-credential.ts, packages/rest/src/rest-server.ts, packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts, packages/runtime/src/domains/mcp.ts, packages/runtime/src/index.ts, packages/runtime/src/security/index.ts, packages/runtime/package.json, and every file the round-1 record lists (packages/core/src/security/api-key.ts, packages/runtime/src/domains/keys.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/api-endpoint-step.ts, packages/rest/src/rest-api-plugin.ts, packages/spec/src/stack.zod.ts, packages/spec/src/kernel/execution-context.zod.ts, packages/spec/src/contracts/auth-service.ts, packages/spec/src/api/error-code-ledger.zod.ts, content/docs/api/environment-routing.mdx, content/docs/api/wire-format.mdx, content/docs/ai/agents.mdx, packages/runtime/src/http-dispatcher.test.ts, packages/runtime/src/http-dispatcher.mcp-oauth.test.ts, .changeset/environment-membership-judges-resolved-principal.md)

This is a DELTA round on the round-1 record, which judged head bb57aaf4a2315b02a9a2f05bbb639177125807d2 PASS with one should-fix (S1: the gate inferred which credential admitted the principal from three facts spelled in the gate itself; read one fact) and one nit (the key arm pinned on /mcp only). The author pushed one commit on top of that head, 44a099f05 (refactor(runtime): the membership check and the api-action forward read one admitting-credential kind); the round-1 head is an ancestor of the new one (no force-push), the merge-base with main is still c2bc76e3e, main has since moved to 405a78eae with no change to any cited file, the changeset is byte-identical, and the PR body changed only in its 「先看」 pointer, the pin count (8/8) and the verification readings. Every judgment of the round-1 record (①②③④) carries over; this record states what moved and re-judges only that, plus the three questions the seat asked.

Check-runs on the new head at the time of writing: 49 runs, 34 completed (18 success, 15 skipped, 1 cancelled: the first PR-body guards run, superseded by its re-run on the same head, which is success; no failure), 15 still in progress (seven Test Core shards, Build Core, Type Check · workspace, Type Check · consumer gates, and five Lint families). Among the completed successes: Check Changeset, Check PR Size, PR-body guards, Governed Surface Queue Guard, Test Core (5/8), Type Check · source gates, Type Check · debt ledger, Lint · docs & code guards, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Live E2E UI, Dogfood Regression Gate, Test UI, Plan Test Core shards, Flag docs affected by code changes. The verdict below stands on the diff and the completed runs; the seat gates landing on the remaining runs finishing green, and a red run reopens this record.

① Derived judgments

  1. What moved between the heads: four files, +48/-11. http-dispatcher.ts (+7/-5): the three-disjunct predicate is replaced by one call, admittingCredentialKind(context), and the arm fires on 'api_key' or 'oauth'. New security/admitting-credential.ts (+25): admittingCredentialKind(request) answers 'oauth' when the resolved context carries an OAuth scope list, else what the identity step's admission recorded ('api_key' or 'session'), else undefined. action-execution.ts (+5/-4): apiForwardCredentialKind delegates to it and ApiForwardCredentialKind becomes an alias of the new kind. The test file (+12/-1): one new case, a key-admitted non-member on the scoped route GET /data/task, refused and named, one membership read. Right, verified against the tree.
  2. Accept set unchanged from round 1. The new predicate is value-identical to the round-1 one on every request that reaches the gate (see ④ a and b), so the callers refused and the callers passed are exactly those the round-1 record judged: on a multi-environment host, a key- or token-admitted caller who is not a member of the resolved environment is refused 403 PROJECT_MEMBERSHIP_REQUIRED naming the resolved user; a session-admitted caller is judged as before; single-environment hosts, the flag-off posture, the skip list, the token-is-the-authorisation exemptions and the system environment return before the arm. Right.
  3. S1 closed. The gate and the api-action forward now read one function for "which credential admitted this caller", so the two cannot disagree; the forward's own reading before this commit was the same two-step test, so its behaviour is unchanged and its tests cover the shared function. The author did not widen the identity step's published admission union; the reason given (the established-session shape is not a token signal) is borne out by the tree: plugin-webhooks' redeliver entitlement establishes a session for the door's own cookie-session user, and REST establishes one for an in-process forward agent, so a widening keyed there would mislabel the webhook door. Reading the fact off the envelope's scope list, in one shared function, is a sound resolution of S1.
  4. Nit closed. The key arm is now pinned on a scoped non-/mcp route as well as on /mcp.
  5. Public surface: unchanged. RequestAdmission (@objectstack/core, re-exported by @objectstack/runtime) is untouched. ApiForwardCredentialKind is now an alias of AdmittingCredentialKind, the same three-literal union it was. Neither that type, the new module nor apiForwardCredentialKind is reachable from the runtime package's exports map (. resolves src/index.ts, which does not re-export action-execution or the new module; security/index.ts does not list it). Metadata-protocol: none still right.
  6. Pins. Eight cases through dispatch() on a stub multi-environment host; the red-first reading in the body (6 failed / 2 passed against main's blob: four refusal cases, two member CONTROLs on the missing read; cookie and no-credential CONTROLs green) is consistent with the tree; not reproduced (read-only).

② Semver level

  • @objectstack/runtime: patch; Clause-②: no; Metadata-protocol: none. Unchanged and still right: the delta is a refactor with no observable change at any caller, and the changeset is the same blob round 1 judged. The round-1 reasoning for no rather than no (narrowing) stands.
  • PR body. Both declaration lines present and identical to the changeset; the verification section now names the new head and the delta commit's purpose. Check Changeset and PR-body guards on the new head: success.
  • Disclosure check on the delta. The commit title and body, the new module's docblock, the two changed comments in http-dispatcher.ts and action-execution.ts, the new test case name and the body's edits were read. All name only the helper, which credential kinds it answers, where the gate reads it and which caller the new pin refuses. Nothing beyond round 1's level. No breach.

③ Boundary flags

  • Round 1's should-fix and nit: both closed (① 3, ① 4).
  • The author's three stated grounds for the chosen shape, each checked rather than taken: (i) the published admission union was left alone because widening it ripples into the key-management credential refusal inputs, REST's admitted-by stamp and the keys domain — confirmed, those three read the two-literal union; (ii) the established-session shape is not a token signal — confirmed (① 3); (iii) the lone agent-kind disjunct was dropped because an agent envelope without a scope list arises only when a key admitted a different user, which already reads 'api_key' — confirmed by reading the shared assembler (④ a).
  • Round 1's three escalated out-of-scope items: unchanged by this delta; nothing to add, nothing reopened.
  • No new flag or open question is raised by the delta commit.

④ Design judgment

The narrowing itself is the one the round-1 record judged, on the same spec on main and the same cited code, and that judgment carries over whole. Re-read for what the delta could have changed, on the three questions asked:

  • (a) Dropping the agent-kind disjunct opens no principal path round 1 covered. The shared assembler marks a principal an agent exactly when an OAuth provenance names a client, and writes the scope list exactly when that provenance's user is the admitted user. The two part only when the admitted user is someone else, and the only arm of the identity step that admits a user ahead of an OAuth provenance is the key arm, which records 'api_key' — so that envelope reads 'api_key' and is judged as the key's owner, the principal the request executes as, exactly as in round 1. The in-process forward provenance always carries a scope list (it is copied as an array at mint and at redemption, from an envelope that had one), so a forward agent reads 'oauth'. No agent envelope reaches the gate with neither signal.
  • (b) The scope list is a sound and unforgeable signal for every token-admitted request. It has one writer, the shared assembler, which sets it from the token provenance alone: a verified access token whose scopes the verifier returned as an array (the identity door accepts no other shape), or a forward provenance typed and copied as an array. A session-admitted principal never carries it; the REST, stdio-MCP and current-user doors never set it. The envelope the gate reads is written only by the dispatcher's own scope-resolution step, from the resolved result; no request header, body or query field flows into it. A token that grants no MCP scope still carries an (empty) list and so still reads 'oauth', which is right: it is a token principal, and the MCP domain refuses its scope separately. The MCP domain already keys its own scope enforcement on the same array test, so the gate and the domain read one fact the same way.
  • (c) No published type changed. RequestAdmission and its two-literal credential are untouched in core; ApiForwardCredentialKind keeps its literal set; the new type and module sit behind the runtime package's export map (① 5).
  • Nit (security/admitting-credential.ts): AdmittingCredentialFacts.admission.credential restates the two-literal union instead of naming the published one (RequestAdmission['credential']). Today they are equal, and the dispatcher's own HttpProtocolContext.admission restates it the same way, so nothing drifts yet; naming the published type would make the shared reader follow it if it ever widens. No effect on correctness or the contract.
  • No must-fix, no should-fix remaining.

Implemented-by: claude/project-thread-fayfxc
Reviewed-by: session_01FEkQ5o8PE7ZtVjcor51wBZ

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5o8PE7ZtVjcor51wBZ

席位意见:接受。QA 第三轮发现的安全加固(细节按披露裁决不公开)。在多环境宿主上,以 API key 或 OAuth 令牌进入的调用方,按其实际身份判断是否为所选环境的成员,与会话进入的调用方同一规则。契约复审与增量复审均 PASS,无必改项。

席位核对 — ACCEPT · domain:server#5 · Head-sha 44a099f055c854599584b254e08b3f65ecc839e7 · 2026-11-08T07:10Z

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」. Security work in this series follows the maintainer's 「所有的安全卡不要创建issue,直接派发处理修复」. The PR therefore refers to the series mailbox (Refs #28284, no closing keyword) and has no card.

  • Shape: 5 files (+227 / −4), read from the diff: packages/runtime/src/http-dispatcher.ts, action-execution.ts, the new security/admitting-credential.ts, one test file and one changeset. The head is the reviewed bb57aaf4 plus one commit that closes the should-fix and the nit. Clause-②: no, Metadata-protocol: none, changeset patch. No published type changed. The new module is outside the package's exports map.
  • Review: CONTRACT_REVIEW_TIER (fable), by a reviewer other than the author. PASS 6718083693 on bb57aaf4, delta PASS 6718435547 on this head. No must-fix and no should-fix remain. Out-of-scope items stay off GitHub under the disclosure ruling. Ruling-ref: 2026-11-07T16:00Z 「你有 fable ,车道契约复审可以自己做」.
  • Gates: head check-runs are 36 success and 15 skipped. One PR-body guards run was cancelled and superseded by a success re-run on the same head. None is red or pending. check-expected-skips: OK (exit 0). check-governed-merges: NOT governed. The author's derived gates: 69 of 69 exit 0. Mergeable state clean.
  • Landing: ungoverned, tier S. Ready and auto-merge go in one relay dispatch.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 8, 2026 07:11
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 8, 2026 07:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit 8941669 Nov 8, 2026
52 of 53 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-fayfxc branch November 8, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants